bookwiz.io / app / api / books / [id] / github-integration / route.ts
route.ts
Raw
import { NextRequest, NextResponse } from 'next/server'
import { createServerSupabaseClient } from '@/lib/supabase'

// GET /api/books/[id]/github-integration - Check if GitHub integration exists
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const supabase = createServerSupabaseClient()
    const bookId = params.id
    
    // Get current user session (this works for client-side requests)
    const authHeader = request.headers.get('authorization')
    let user
    
    try {
      if (authHeader) {
        // Handle if there's an auth header
        const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(authHeader.replace('Bearer ', ''))
        if (!authError) user = authUser
      } else {
        // Try to get user from session
        const { data: { user: sessionUser }, error: sessionError } = await supabase.auth.getUser()
        if (!sessionError) user = sessionUser
      }
    } catch (e) {
      // Ignore auth errors for now
    }

    if (!user) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Verify user owns this book
    const { data: book, error: bookError } = await supabase
      .from('books')
      .select('id, user_id')
      .eq('id', bookId)
      .eq('user_id', user.id)
      .single()

    if (bookError || !book) {
      return NextResponse.json(
        { error: 'Book not found or access denied' },
        { status: 404 }
      )
    }

    // Check for GitHub integration in user metadata
    const { data: profile } = await supabase
      .from('profiles')
      .select('github_integrations')
      .eq('id', user.id)
      .single()

    const githubIntegrations = profile?.github_integrations || {}
    const integration = githubIntegrations[bookId]

    if (integration) {
      return NextResponse.json({
        hasIntegration: true,
        repository_name: integration.repository_name,
        repository_full_name: integration.repository_full_name,
        repository_url: integration.repository_url,
        github_username: integration.github_username,
        is_private: integration.is_private,
        connected_at: integration.connected_at
      })
    } else {
      return NextResponse.json({
        hasIntegration: false
      })
    }
  } catch (error) {
    console.error('Error checking GitHub integration:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

// POST /api/books/[id]/github-integration - Set up GitHub integration via OAuth
export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const supabase = createServerSupabaseClient()
    const bookId = params.id
    const { integration } = await request.json()

    if (!integration) {
      return NextResponse.json(
        { error: 'Integration data is required' },
        { status: 400 }
      )
    }

    // Get user ID from integration data
    const userId = integration.user_id
    if (!userId) {
      return NextResponse.json(
        { error: 'User ID required' },
        { status: 400 }
      )
    }

    // Verify user owns this book
    const { data: book, error: bookError } = await supabase
      .from('books')
      .select('id, user_id, title')
      .eq('id', bookId)
      .eq('user_id', userId)
      .single()

    if (bookError || !book) {
      return NextResponse.json(
        { error: 'Book not found or access denied' },
        { status: 404 }
      )
    }

    // Verify GitHub repo access
    try {
      const repoResponse = await fetch(`https://api.github.com/repos/${integration.repository_full_name}`, {
        headers: {
          'Authorization': `Bearer ${integration.access_token}`,
          'Accept': 'application/vnd.github.v3+json'
        }
      })

      if (!repoResponse.ok) {
        return NextResponse.json(
          { error: 'Cannot access GitHub repository. Please check permissions.' },
          { status: 400 }
        )
      }
    } catch {
      return NextResponse.json(
        { error: 'Failed to verify GitHub repository access' },
        { status: 400 }
      )
    }

    // Get current profile
    const { data: profile } = await supabase
      .from('profiles')
      .select('github_integrations')
      .eq('id', userId)
      .single()

    const githubIntegrations = profile?.github_integrations || {}
    
    // Add this book's integration
    githubIntegrations[bookId] = {
      provider: integration.provider,
      access_token: integration.access_token,
      repository_url: integration.repository_url,
      repository_name: integration.repository_name,
      repository_full_name: integration.repository_full_name,
      github_username: integration.github_username,
      repository_id: integration.repository_id,
      is_private: integration.is_private,
      connected_at: new Date().toISOString()
    }

    // Update profile with GitHub integration
    const { error: updateError } = await supabase
      .from('profiles')
      .update({ 
        github_integrations: githubIntegrations,
        updated_at: new Date().toISOString()
      })
      .eq('id', userId)

    if (updateError) {
      console.error('Error updating profile:', updateError)
      return NextResponse.json(
        { error: 'Failed to save GitHub integration' },
        { status: 500 }
      )
    }

    // Make initial commit with all book files
    try {
      await makeInitialCommit(supabase, bookId, userId, integration)
    } catch (commitError) {
      console.error('Failed to make initial commit:', commitError)
      // Don't fail the integration setup if initial commit fails
    }

    return NextResponse.json({
      message: 'GitHub integration set up successfully',
      repository_name: integration.repository_name,
      repository_full_name: integration.repository_full_name,
      repository_url: integration.repository_url,
      github_username: integration.github_username,
      is_private: integration.is_private
    })
  } catch (error) {
    console.error('Error setting up GitHub integration:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

// DELETE /api/books/[id]/github-integration - Remove GitHub integration
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const supabase = createServerSupabaseClient()
    const bookId = params.id
    
    // Get current user
    const { data: { user }, error: authError } = await supabase.auth.getUser()
    if (authError || !user) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      )
    }

    // Verify user owns this book
    const { data: book, error: bookError } = await supabase
      .from('books')
      .select('id, user_id')
      .eq('id', bookId)
      .eq('user_id', user.id)
      .single()

    if (bookError || !book) {
      return NextResponse.json(
        { error: 'Book not found or access denied' },
        { status: 404 }
      )
    }

    // Get current profile
    const { data: profile } = await supabase
      .from('profiles')
      .select('github_integrations')
      .eq('id', user.id)
      .single()

    const githubIntegrations = profile?.github_integrations || {}
    
    // Remove this book's integration
    delete githubIntegrations[bookId]

    // Update profile
    const { error: updateError } = await supabase
      .from('profiles')
      .update({ 
        github_integrations: githubIntegrations,
        updated_at: new Date().toISOString()
      })
      .eq('id', user.id)

    if (updateError) {
      console.error('Error updating profile:', updateError)
      return NextResponse.json(
        { error: 'Failed to remove GitHub integration' },
        { status: 500 }
      )
    }

    return NextResponse.json({
      message: 'GitHub integration removed successfully'
    })
  } catch (error) {
    console.error('Error removing GitHub integration:', error)
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    )
  }
}

// Helper function to make initial commit with all book files
async function makeInitialCommit(supabase: any, bookId: string, userId: string, integration: any) {
  // Get all book files
  const { data: files } = await supabase
    .from('file_system_items')
    .select('*')
    .eq('book_id', bookId)

  if (!files || files.length === 0) {
    throw new Error('No files found to commit')
  }

  // Build file structure
  const buildFilePath = (fileId: string, allFiles: any[]): string => {
    const file = allFiles.find(f => f.id === fileId)
    if (!file) return ''
    
    // For files, ensure extension is included
    let fileName = file.name
    if (file.type === 'file' && file.file_extension && !fileName.includes('.')) {
      fileName = `${fileName}.${file.file_extension}`
    }
    
    if (!file.parent_id) return fileName
    
    const parentPath = buildFilePath(file.parent_id, allFiles)
    return parentPath ? `${parentPath}/${fileName}` : fileName
  }

  // Create file contents for commit
  const fileContents: { [path: string]: string } = {}
  files.forEach((file: any) => {
    if (file.type === 'file') {
      const filePath = buildFilePath(file.id, files)
      fileContents[filePath] = file.content || ''
    }
  })

  if (Object.keys(fileContents).length === 0) {
    throw new Error('No file content to commit')
  }

  // GitHub API details
  const owner = integration.github_username
  const repo = integration.repository_name
  const accessToken = integration.access_token

  console.log(`Making initial commit for ${owner}/${repo}`)

  // Add a small delay to allow GitHub to fully initialize the repository
  await new Promise(resolve => setTimeout(resolve, 1000))

  // Try to get current branch reference (might not exist for empty repo)
  const branchResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json'
    }
  })

  let parentSha = null
  let isEmptyRepo = false

  if (branchResponse.ok) {
    const branchData = await branchResponse.json()
    parentSha = branchData.object.sha
    console.log(`Found existing branch with SHA: ${parentSha}`)
  } else if (branchResponse.status === 404 || branchResponse.status === 409) {
    // Empty repository - no main branch exists yet (404) or repository is empty (409)
    isEmptyRepo = true
    console.log(`Empty repository detected (${branchResponse.status}) - will create initial commit`)
  } else {
    // Log the actual error for debugging
    const errorText = await branchResponse.text()
    console.error(`GitHub API error (${branchResponse.status}):`, errorText)
    throw new Error(`Failed to get branch reference: ${branchResponse.status} - ${errorText}`)
  }

  // For empty repositories, create tree directly without creating blobs first
  if (isEmptyRepo) {
    console.log('Creating tree directly for empty repository')
    
    // For truly empty repositories, we need to initialize with a dummy commit first
    // then we can use the proper Git Tree API
    console.log('Initializing empty repository with dummy commit')
    
    try {
      // Create a simple initial commit to initialize the repository
      const initResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/README.md`, {
        method: 'PUT',
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Accept': 'application/vnd.github.v3+json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          message: 'Initialize repository',
          content: Buffer.from('# Repository initialized by BookWiz\n\nThis repository will contain your book files.').toString('base64')
        })
      })

      if (!initResponse.ok) {
        const errorText = await initResponse.text()
        console.error(`Failed to initialize repository (${initResponse.status}):`, errorText)
        throw new Error(`Failed to initialize repository: ${initResponse.status}`)
      }

      const initResult = await initResponse.json()
      const initCommitSha = initResult.commit.sha
      console.log(`Repository initialized with commit: ${initCommitSha}`)

      // Now we can use the regular Git Tree API with this as the base
      parentSha = initCommitSha
      isEmptyRepo = false // Treat it as a normal repo now
      
      // Continue to the normal blob creation process below
    } catch (initError) {
      console.error('Failed to initialize repository, falling back to Contents API:', initError)
      
      // Ultimate fallback - but let's make it create a single commit by combining files
      console.log('Using combined Contents API approach for empty repository')
      
      // Create all files at once using multiple concurrent requests but with the same commit message
      // This is still not ideal but better than sequential commits
      const fileEntries = Object.entries(fileContents)
      
      if (fileEntries.length === 1) {
        // If only one file, create it directly
        const [path, content] = fileEntries[0]
        const fileResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
          method: 'PUT',
          headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Accept': 'application/vnd.github.v3+json',
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            message: 'Initial commit: Add book files from BookWiz',
            content: Buffer.from(content).toString('base64')
          })
        })

        if (!fileResponse.ok) {
          const errorText = await fileResponse.text()
          throw new Error(`Failed to create file ${path}: ${fileResponse.status}`)
        }
        
        const result = await fileResponse.json()
        console.log('Created single file using Contents API')
        return result.commit
      } else {
        // Multiple files - this will unfortunately create multiple commits
        // but it's the only option for truly empty repos when Git Tree API fails
        console.log('WARNING: Will create multiple commits due to GitHub API limitations with empty repositories')
        let lastCommit = null
        
        for (const [path, content] of fileEntries) {
          console.log(`Creating file: ${path}`)
          
          const fileResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`, {
            method: 'PUT',
            headers: {
              'Authorization': `Bearer ${accessToken}`,
              'Accept': 'application/vnd.github.v3+json',
              'Content-Type': 'application/json'
            },
            body: JSON.stringify({
              message: 'Initial commit: Add book files from BookWiz',
              content: Buffer.from(content).toString('base64')
            })
          })

          if (!fileResponse.ok) {
            const errorText = await fileResponse.text()
            console.error(`Failed to create file ${path} (${fileResponse.status}):`, errorText)
            throw new Error(`Failed to create file ${path}: ${fileResponse.status}`)
          }
          
          const result = await fileResponse.json()
          lastCommit = result.commit
        }
        
        console.log(`Created ${fileEntries.length} files using Contents API fallback`)
        return lastCommit
      }
    }
  }

  // Create blobs and tree entries
  const treeEntries = await Promise.all(
    Object.entries(fileContents).map(async ([path, content]) => {
      const blobResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/blobs`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Accept': 'application/vnd.github.v3+json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          content: Buffer.from(content).toString('base64'),
          encoding: 'base64'
        })
      })

      if (!blobResponse.ok) {
        const errorText = await blobResponse.text()
        console.error(`Failed to create blob for ${path} (${blobResponse.status}):`, errorText)
        throw new Error(`Failed to create blob for ${path}: ${blobResponse.status}`)
      }

      const blob = await blobResponse.json()
      return {
        path,
        mode: '100644',
        type: 'blob',
        sha: blob.sha
      }
    })
  )

  // Create new tree
  const treePayload: any = {
    tree: treeEntries
  }
  
  // Set base_tree if we have a parent commit
  if (parentSha) {
    treePayload.base_tree = parentSha
  }

  const newTreeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(treePayload)
  })

  if (!newTreeResponse.ok) {
    const errorText = await newTreeResponse.text()
    console.error(`Failed to create tree (${newTreeResponse.status}):`, errorText)
    throw new Error(`Failed to create tree: ${newTreeResponse.status}`)
  }

  const newTree = await newTreeResponse.json()

  // Create commit
  const commitPayload: any = {
    message: 'Initial commit: Add book files from BookWiz',
    tree: newTree.sha
  }
  
  // Set parents if we have a parent commit
  if (parentSha) {
    commitPayload.parents = [parentSha]
  }

  const commitResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/commits`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(commitPayload)
  })

  if (!commitResponse.ok) {
    const errorText = await commitResponse.text()
    console.error(`Failed to create commit (${commitResponse.status}):`, errorText)
    throw new Error(`Failed to create commit: ${commitResponse.status}`)
  }

  const commit = await commitResponse.json()

  // Update branch reference (always update since we now have a parent commit)
  const updateRefResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, {
    method: 'PATCH',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      sha: commit.sha
    })
  })

  if (!updateRefResponse.ok) {
    const errorText = await updateRefResponse.text()
    console.error(`Failed to update branch reference (${updateRefResponse.status}):`, errorText)
    throw new Error(`Failed to update branch reference: ${updateRefResponse.status}`)
  }

  return commit
}